fix(paywall): use dynamic token decimals instead of hardcoding 6#1980
Open
ryanRfox wants to merge 3 commits intox402-foundation:mainfrom
Open
fix(paywall): use dynamic token decimals instead of hardcoding 6#1980ryanRfox wants to merge 3 commits intox402-foundation:mainfrom
ryanRfox wants to merge 3 commits intox402-foundation:mainfrom
Conversation
f08dc05 to
9a67807
Compare
|
@ryanRfox is attempting to deploy a commit to the Coinbase Team on Vercel. A member of the Team first needs to authorize it. |
Contributor
Author
|
Note: #1926 fixes the same hardcoded-decimals bug in the server-side fallback paywall ( |
6dad9dd to
178b613
Compare
Contributor
Author
|
@phdargen this one is ready for review. Hope you can consider this in parallel with #1926 to ensure @natsukingly and I land a complete fix. I saw your comment recently about the next SDK release coming early this week, so hoping these can make it in as well. |
The EVM paywall displays incorrect amounts for any token that does not use 6 decimal places. An 18-decimal token priced at 0.001 raw units renders as $1,000,000,000 because the code divides by 10^6 everywhere. Root cause: the paywall was built assuming USDC (6 decimals) is the only payment token. This assumption appears in three places: 1. Server-side HTML generation (index.ts) divides the raw amount by a hardcoded 10^6 to produce the display value injected into the template. 2. Client-side balance display (EvmPaywall.tsx) calls formatUnits with a hardcoded 6 and reads the token address from a 3-chain USDC lookup table that returns 0 for any other chain. 3. The balance utility (utils.ts) only knows USDC addresses on Ethereum, Base, and Base Sepolia — every other chain silently shows a zero balance. Fix: Server-side (index.ts): look up the token's decimal precision from a known-decimals map aligned with DEFAULT_STABLECOINS. Only non-6-decimal chains need entries; everything else falls back to 6. This keeps the server-side path simple with no RPC calls. Client-side (EvmPaywall.tsx): read the token contract address from the payment requirement's `asset` field (which the server already populates) and query the standard ERC-20 decimals() function on-chain. This works for any compliant token without maintaining a lookup table. utils.ts: replace the USDC-specific getUSDCBalance (internal helper, not part of the public API) with generic getTokenBalance and getTokenDecimals functions that accept a token address parameter. The function signature changes from 2 args to 3, but no external consumer can import it — it is only used by the bundled React component inside the paywall HTML template. **Breaking change note:** the internal helper getUSDCBalance in utils.ts is removed and replaced by getTokenBalance (different signature: takes an explicit token address instead of looking it up by chain ID). This function is not exported from the package's public API — it is only consumed by the bundled EvmPaywall React component inside the paywall HTML template. No server operator code calls it directly. However, anyone who has forked the paywall and imports from the file path (bypassing package exports) would need to update their import and call site. Regenerated Go, Python, and TS paywall templates. **AI disclosure:** This PR was prepared with the assistance of a coding agent and reviewed by Ryan R. Fox (an actual human) before submission.
178b613 to
55da33a
Compare
Removes the parallel KNOWN_DECIMALS map in @x402/paywall/src/evm/index.ts
that mirrored DEFAULT_STABLECOINS from @x402/evm. The paywall now resolves
token decimals through the same registry that scheme implementations and
@x402/core's inline scheme dispatch already read from.
evmPaywall.generateHtml changes:
* getDefaultTokenDecimals helper looks up requirement.network directly in
@x402/evm's DEFAULT_STABLECOINS, with a 6 (USDC default) fallback when
the network is unknown.
* Atomic-to-display conversion uses Number(formatUnits(BigInt(amount),
decimals)) instead of parseFloat(amount) / 10**decimals. parseFloat
silently rounds sub-cent precision on real 18-decimal amounts before
the divide; BigInt + formatUnits preserves precision through the
conversion. BigInt also rejects non-integer atomic strings, which
matches the spec's atomic-integer contract.
@x402/paywall package.json: @x402/evm moves from devDependencies to
dependencies because the new server-side import of DEFAULT_STABLECOINS is
on the runtime path emitted into dist/.
@x402/evm now publicly re-exports DEFAULT_STABLECOINS from
./shared/defaultAssets so consumers can read the canonical default-asset
registry directly. Both @x402/evm and @x402/paywall get a minor bump
for the additive public API.
Adds 6 unit tests:
* getDefaultTokenDecimals returns 18 for Mezo Testnet (registry path)
* getDefaultTokenDecimals returns 6 for Base mainnet, asserted alongside
DEFAULT_STABLECOINS so an empty registry would fail the test
* getDefaultTokenDecimals falls back to 6 for unknown networks
* evmPaywall.generateHtml renders 1e15-atomic Mezo mUSD as
'amount: 0.001,' end-to-end (regression test for the /1e6 bug)
* evmPaywall.generateHtml renders 1e6-atomic Base USDC as 'amount: 1,'
* evmPaywall.generateHtml throws on a non-integer atomic ('1.5'),
pinning the BigInt strictness so a future revert to parseFloat fails
Closes x402-foundation#1979.
3b2fc7f to
117b8fc
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
The EVM paywall hardcodes
/ 1000000everywhere, breaking display amounts for any token that isn't a 6-decimal stablecoin. An 18-decimal token priced at 0.001 raw units renders as $1,000,000,000. The assumption appears in three places — server-side HTML generation, client-side balance display, and a USDC-only address lookup table.Closes #1979.
Approach
The PR replaces the hardcoded-6-decimal assumption on both sides of the paywall:
Server side (
@x402/paywall/src/evm/index.ts)/ 1000000divisor with a lookup against@x402/evm'sDEFAULT_STABLECOINSregistry, with a fallback to6(USDC default) when the network isn't in the registry.parseFloat(amount) / 10**decimalsmath withNumber(formatUnits(BigInt(amount), decimals))so atomic-to-display conversion preserves precision through 18-decimal amounts.BigIntalso rejects non-integer atomic strings, which matches the spec's atomic-integer contract.Client side (
@x402/paywall/src/evm/EvmPaywall.tsx)assetfield (server populates it).decimals()function on-chain via the newgetTokenDecimalshelper inutils.ts, so balance formatting uses the actual token's precision.formattedUsdcBalance→formattedBalance; balance-check callback renamedcheckUSDCBalance→checkBalance.Internal utility (
@x402/paywall/src/evm/utils.ts)getUSDCBalance(client, address)— which hardcoded USDC contract addresses for three chains (Ethereum, Base, Base Sepolia) and returned0nfor every other chain — with generic:getTokenBalance(client, owner, tokenAddress)— ERC-20balanceOfagainst the supplied token addressgetTokenDecimals(client, tokenAddress)— ERC-20decimals()against the supplied token address, with a6fallback on errorUSDC_ADDRESSESmap entirely.getUSDCBalanceis removed. This helper was not part of the package's public API (exportsmap); it was only consumed by the bundledEvmPaywallReact component inside the paywall HTML template. No server-operator code path calls it. Anyone who has forked the paywall and imports from the internal file path (bypassing package exports) would need to update their import and call site.@x402/evmpublic API@x402/evmnow publicly re-exportsDEFAULT_STABLECOINSfrom./shared/defaultAssetsso consumers can read the canonical default-asset registry directly.Generated templates
The baked EVM paywall bundles (
typescript/packages/http/paywall/src/evm/gen/template.ts,go/http/evm_paywall_template.go,python/x402/http/paywall/evm_paywall_template.py) are regenerated to carry the updatedEvmPaywall.tsx/utils.ts. SVM templates are regenerated for toolchain parity — the build step emits both.Why this approach
@x402/evm'sDEFAULT_STABLECOINSregistry already holds the decimals field for every supported chain (eip155:31611 / Mezo mUSD = 18, eip155:4326 / MegaETH MegaUSD = 18, every other chain's default = 6). The same registry powers existing dispatch paths in the SDK:scheme.getAssetDecimals(asset, network)on both the exact and upto EVM schemesscheme?.getAssetDecimals?.(...)dispatch in@x402/core'sx402ResourceServer(server.ts:795)The paywall now consolidates the registry lookup so all consumers share the same source within a published version, rather than carrying a parallel map. A new chain added to
DEFAULT_STABLECOINSis picked up by the paywall when the consumer upgrades@x402/evm— no paywall rebuild required.@x402/paywallalready imports from@x402/evmfor client-side code; this PR extends that to the server-side decimals lookup.Tests
pnpm --filter @x402/paywall test --run network-handlers— 12/12 pass (6 new tests added):getDefaultTokenDecimalsreturns 18 for Mezo Testnet (eip155:31611)getDefaultTokenDecimalsreturns 6 for Base mainnet (eip155:8453), withDEFAULT_STABLECOINS["eip155:8453"]asserted alongside so an empty registry would fail the testgetDefaultTokenDecimalsfalls back to 6 for networks not in the registryevmPaywall.generateHtmlend-to-end on a Mezo Testnet 18-decimal payment:1000000000000000atomic renders asamount: 0.001,in the inline window.x402 script (the regression test for the/ 1e6order-of-magnitude bug)evmPaywall.generateHtmlend-to-end on a Base mainnet 6-decimal payment:1000000atomic renders asamount: 1,evmPaywall.generateHtmlthrows on a non-integer atomic amount string ("1.5"), pinning the BigInt strictness so a future revert toparseFloatwould fail